import
  os, sequtils, strutils, options, json, terminal, random,
  chronos, chronicles, confutils, stint, json_serialization,
  ../beacon_chain/network_metadata,
  web3, web3/confutils_defs, eth/keys, stew/io2, filepath,
  spec/[datatypes, crypto, presets], ssz/merkleization, keystore_management

# Compiled version of /scripts/depositContract.v.py in this repo
# The contract was compiled in Remix (https://remix.ethereum.org/) with vyper (remote) compiler.
const contractCode = staticRead "deposit_contract_code.txt"

type
  Eth1Address = web3.Address

  StartUpCommand {.pure.} = enum
    deploy
    drain
    sendEth
    generateSimulationDeposits
    sendDeposits

  CliConfig = object
    web3Url* {.
      defaultValue: "",
      desc: "URL of the Web3 server to observe Eth1"
      name: "web3-url" }: string

    privateKey* {.
      defaultValue: ""
      desc: "Private key of the controlling account"
      name: "private-key" }: string

    askForKey {.
      defaultValue: false
      desc: "Ask for an Eth1 private key interactively"
      name: "ask-for-key" }: bool

    eth2Network* {.
      desc: "The Eth2 network preset to use"
      name: "network" }: Option[string]

    case cmd* {.command.}: StartUpCommand
    of deploy:
      discard

    of drain:
      drainedContractAddress* {.
        desc: "Address of the contract to drain"
        name: "deposit-contract" }: Eth1Address

    of sendEth:
      toAddress {.name: "to".}: Eth1Address
      valueEth {.name: "eth".}: string

    of generateSimulationDeposits:
      simulationDepositsCount {.
        desc: "The number of validator keystores to generate"
        name: "count" }: Natural

      outValidatorsDir {.
        desc: "A directory to store the generated validator keystores"
        name: "out-validators-dir" }: OutDir

      outSecretsDir {.
        desc: "A directory to store the generated keystore password files"
        name: "out-secrets-dir" }: OutDir

      outDepositsFile {.
        desc: "A LaunchPad deposits file to write"
        name: "out-deposits-file" }: OutFile

    of sendDeposits:
      depositsFile {.
        desc: "A LaunchPad deposits file"
        name: "deposits-file" }: InputFile

      depositContractAddress {.
        desc: "Address of the deposit contract"
        name: "deposit-contract" }: Eth1Address

      minDelay {.
        defaultValue: 0.0
        desc: "Minimum possible delay between making two deposits (in seconds)"
        name: "min-delay" }: float

      maxDelay {.
        defaultValue: 0.0
        desc: "Maximum possible delay between making two deposits (in seconds)"
        name: "max-delay" }: float

contract(DepositContract):
  proc deposit(pubkey: Bytes48,
               withdrawalCredentials: Bytes32,
               signature: Bytes96,
               deposit_data_root: FixedBytes[32])

  proc drain()

proc deployContract*(web3: Web3, code: string): Future[ReceiptObject] {.async.} =
  var code = code
  if code[1] notin {'x', 'X'}:
    code = "0x" & code
  let tr = EthSend(
    source: web3.defaultAccount,
    data: code,
    gas: Quantity(3000000).some,
    gasPrice: 1.some)

  let r = await web3.send(tr)
  result = await web3.getMinedTransactionReceipt(r)

proc sendEth(web3: Web3, to: Eth1Address, valueEth: int): Future[TxHash] =
  let tr = EthSend(
    source: web3.defaultAccount,
    gas: Quantity(3000000).some,
    gasPrice: 1.some,
    value: some(valueEth.u256 * 1000000000000000000.u256),
    to: some(to))
  web3.send(tr)

type
  DelayGenerator* = proc(): chronos.Duration {.closure, gcsafe.}

proc ethToWei(eth: UInt256): UInt256 =
  eth * 1000000000000000000.u256

proc initWeb3(web3Url, privateKey: string): Future[Web3] {.async.} =
  result = await newWeb3(web3Url)
  if privateKey.len != 0:
    result.privateKey = some(PrivateKey.fromHex(privateKey)[])
  else:
    let accounts = await result.provider.eth_accounts()
    doAssert(accounts.len > 0)
    result.defaultAccount = accounts[0]

# TODO: async functions should note take `seq` inputs because
#       this leads to full copies.
proc sendDeposits*(deposits: seq[LaunchPadDeposit],
                   web3Url, privateKey: string,
                   depositContractAddress: Eth1Address,
                   delayGenerator: DelayGenerator = nil) {.async.} =
  notice "Sending deposits",
    web3 = web3Url,
    depositContract = depositContractAddress

  var web3 = await initWeb3(web3Url, privateKey)
  let depositContract = web3.contractSender(DepositContract,
                                            Address depositContractAddress)
  for i, launchPadDeposit in deposits:
    let dp = launchPadDeposit as DepositData

    while true:
      try:
        let tx = depositContract.deposit(
          Bytes48(dp.pubKey.toRaw()),
          Bytes32(dp.withdrawal_credentials.data),
          Bytes96(dp.signature.toRaw()),
          FixedBytes[32](hash_tree_root(dp).data))

        let status = await tx.send(value = 32.u256.ethToWei, gasPrice = 1)

        info "Deposit sent", status = $status

        if delayGenerator != nil:
          await sleepAsync(delayGenerator())

        break
      except CatchableError:
        await sleepAsync(60.seconds)
        web3 = await initWeb3(web3Url, privateKey)

proc main() {.async.} =
  var cfg = CliConfig.load()
  let rng = keys.newRng()

  if cfg.cmd == StartUpCommand.generateSimulationDeposits:
    let
      mnemonic = generateMnemonic(rng[])
      seed = getSeed(mnemonic, KeyStorePass.init "")
      runtimePreset = getRuntimePresetForNetwork(cfg.eth2Network)

    let vres = secureCreatePath(string cfg.outValidatorsDir)
    if vres.isErr():
      warn "Could not create validators folder",
           path = string cfg.outValidatorsDir, err = ioErrorMsg(vres.error)

    let sres = secureCreatePath(string cfg.outSecretsDir)
    if sres.isErr():
      warn "Could not create secrets folder",
           path = string cfg.outSecretsDir, err = ioErrorMsg(sres.error)

    let deposits = generateDeposits(
      runtimePreset,
      rng[],
      seed,
      0, cfg.simulationDepositsCount,
      string cfg.outValidatorsDir,
      string cfg.outSecretsDir)

    if deposits.isErr:
      fatal "Failed to generate deposits", err = deposits.error
      quit 1

    let launchPadDeposits =
      mapIt(deposits.value, LaunchPadDeposit.init(runtimePreset, it))

    Json.saveFile(string cfg.outDepositsFile, launchPadDeposits)
    notice "Deposit data written", filename = cfg.outDepositsFile
    quit 0

  var deposits: seq[LaunchPadDeposit]
  if cfg.cmd == StartUpCommand.sendDeposits:
    deposits = Json.loadFile(string cfg.depositsFile, seq[LaunchPadDeposit])

  if cfg.askForKey:
    var
      privateKey: TaintedString
      reasonForKey = ""

    if cfg.cmd == StartUpCommand.sendDeposits:
      let
        depositsWord = if deposits.len > 1: "deposits" else: "deposit"
        totalEthNeeded = 32 * deposits.len
      reasonForKey = " in order to make your $1 (you'll need access to $2 ETH)" %
                     [depositsWord, $totalEthNeeded]

    echo "Please enter your Goerli Eth1 private key in hex form (e.g. 0x1a2...f3c)" &
          reasonForKey

    if not readPasswordFromStdin("> ", privateKey):
      error "Failed to read an Eth1 private key from standard input"

    if privateKey.len > 0:
      cfg.privateKey = privateKey.string

  let web3 = await initWeb3(cfg.web3Url, cfg.privateKey)

  case cfg.cmd
  of StartUpCommand.deploy:
    let receipt = await web3.deployContract(contractCode)
    echo receipt.contractAddress.get, ";", receipt.blockHash

  of StartUpCommand.drain:
    let sender = web3.contractSender(DepositContract,
                                     cfg.drainedContractAddress)
    discard await sender.drain().send(gasPrice = 1)

  of StartUpCommand.sendEth:
    echo await sendEth(web3, cfg.toAddress, cfg.valueEth.parseInt)

  of StartUpCommand.sendDeposits:
    var delayGenerator: DelayGenerator
    if not (cfg.maxDelay > 0.0):
      cfg.maxDelay = cfg.minDelay
    elif cfg.minDelay > cfg.maxDelay:
      echo "The minimum delay should not be larger than the maximum delay"
      quit 1

    if cfg.maxDelay > 0.0:
      delayGenerator = proc (): chronos.Duration =
        chronos.milliseconds (rand(cfg.minDelay..cfg.maxDelay)*1000).int

    await sendDeposits(deposits, cfg.web3Url, cfg.privateKey,
                       cfg.depositContractAddress, delayGenerator)

  of StartUpCommand.generateSimulationDeposits:
    # This is handled above before the case statement
    discard

when isMainModule: waitFor main()
